前面已經學過各種測試技術,從基礎單元測試到 AutoFixture、Bogus 等進階工具。今天要解決一個很實際的問題:時間相依性的測試。
看看這些常見的開發情境:
這些功能在實際運作時都會直接使用 DateTime.Now
或 DateTime.Today
,但這就造成測試上的困難:根本沒辦法控制「現在」是什麼時候。
今天就來看看如何用 Microsoft.Bcl.TimeProvider 解決這個根本問題,讓時間相依的邏輯可以被完整測試。
先來看看一個典型的時間相依程式碼:
public class OrderService
{
public bool CanPlaceOrder()
{
var now = DateTime.Now;
var currentHour = now.Hour;
// 營業時間:上午9點到下午5點
return currentHour >= 9 && currentHour < 17;
}
public string GetTimeBasedDiscount()
{
var today = DateTime.Today;
if (today.DayOfWeek == DayOfWeek.Friday)
{
return "週五快樂:九折優惠";
}
if (today.Month == 12 && today.Day == 25)
{
return "聖誕特惠:八折優惠";
}
return "無優惠";
}
}
這段程式碼看起來很簡單,但要為它寫測試時,就會遇到幾個嚴重的問題:
[Fact]
public void CanPlaceOrder_在營業時間內_應回傳True()
{
// Arrange
var orderService = new OrderService();
// Act
var result = orderService.CanPlaceOrder();
// Assert
// 這個測試會根據執行時間而有不同結果!
result.Should().BeTrue(); // 可能通過,也可能失敗
}
這個測試的問題就是結果完全取決於執行時間。下午 3 點執行會通過,晚上 8 點執行就失敗。
要怎麼測試「剛好上午 9 點」或「剛好下午 5 點」的情況?除非能精確控制測試執行時間,否則這些重要的邊界條件就無法驗證。
在 CI/CD 環境中,多個測試可能會並行執行。如果多個測試都依賴當前時間,就可能出現不可預期的結果。
除了基本的日期時間問題,還會遇到更複雜的情況:
public class AuditLogger
{
public void LogActivity(string activity)
{
var timestamp = DateTime.UtcNow;
var localTime = TimeZoneInfo.ConvertTimeFromUtc(timestamp, TimeZoneInfo.Local);
Console.WriteLine($"[{localTime:yyyy-MM-dd HH:mm:ss}] {activity}");
}
}
這個程式碼涉及:
每一個環節都可能成為測試的障礙。
Microsoft.Bcl.TimeProvider 是微軟提供的時間抽象層,解決了傳統 DateTime 測試的根本問題。
TimeProvider 的核心概念是時間抽象化。它將「取得當前時間」這個動作抽象為一個可以注入的服務,讓你能夠:
// TimeProvider 是抽象類別,定義了時間相關的核心功能
public abstract class TimeProvider
{
// 取得 UTC 時間
public abstract DateTimeOffset GetUtcNow();
// 取得本地時間
public virtual DateTimeOffset GetLocalNow()
=> TimeZoneInfo.ConvertTime(GetUtcNow(), LocalTimeZone);
// 取得本地時區
public abstract TimeZoneInfo LocalTimeZone { get; }
// 高精度時間戳
public virtual long GetTimestamp()
=> Stopwatch.GetTimestamp();
// 計算時間差
public TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp)
=> new((long)((endingTimestamp - startingTimestamp) * (10_000_000.0 / Stopwatch.Frequency)));
}
// 系統預設的 TimeProvider,直接使用系統時間
public static TimeProvider System { get; } = new SystemTimeProvider();
private sealed class SystemTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Local;
}
把剛才的 OrderService 重構為可測試的版本:
public class OrderService
{
private readonly TimeProvider _timeProvider;
public OrderService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public bool CanPlaceOrder()
{
var now = _timeProvider.GetLocalNow();
var currentHour = now.Hour;
// 營業時間:上午9點到下午5點
return currentHour >= 9 && currentHour < 17;
}
public string GetTimeBasedDiscount()
{
var today = _timeProvider.GetLocalNow().Date;
if (today.DayOfWeek == DayOfWeek.Friday)
{
return "週五快樂:九折優惠";
}
if (today.Month == 12 && today.Day == 25)
{
return "聖誕特惠:八折優惠";
}
return "無優惠";
}
}
// Program.cs 或 Startup.cs
services.AddSingleton(TimeProvider.System);
services.AddScoped<OrderService>();
Microsoft.Extensions.TimeProvider.Testing 套件提供了 FakeTimeProvider,這是專門為測試設計的時間提供者。用它可以在測試中完全控制時間的流逝,就像擁有時光機一樣。
先看看如何用 FakeTimeProvider 來控制測試中的時間:
public class OrderServiceTests
{
[Fact]
public void CanPlaceOrder_在營業時間內_應回傳True()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
// 設定為下午 2 點
var testTime = new DateTime(2024, 3, 15, 14, 0, 0, DateTimeKind.Local);
fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(testTime));
var orderService = new OrderService(fakeTimeProvider);
// Act
var result = orderService.CanPlaceOrder();
// Assert
result.Should().BeTrue();
}
[Fact]
public void CanPlaceOrder_在營業時間外_應回傳False()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
// 設定為晚上 8 點
var testTime = new DateTime(2024, 3, 15, 20, 0, 0, DateTimeKind.Local);
fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(testTime));
var orderService = new OrderService(fakeTimeProvider);
// Act
var result = orderService.CanPlaceOrder();
// Assert
result.Should().BeFalse();
}
}
由於設定本地時間的步驟有點繁瑣,可以建立一個擴充方法:
public static class FakeTimeProviderExtensions
{
/// <summary>
/// 設定 FakeTimeProvider 的本地時間
/// </summary>
/// <param name="fakeTimeProvider">FakeTimeProvider 實例</param>
/// <param name="localDateTime">要設定的本地時間</param>
public static void SetLocalNow(this FakeTimeProvider fakeTimeProvider, DateTime localDateTime)
{
fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
var utcTime = TimeZoneInfo.ConvertTimeToUtc(localDateTime, TimeZoneInfo.Local);
fakeTimeProvider.SetUtcNow(utcTime);
}
}
使用擴充方法後,測試程式碼變得更簡潔:
[Fact]
public void CanPlaceOrder_在營業時間內_應回傳True()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
fakeTimeProvider.SetLocalNow(new DateTime(2024, 3, 15, 14, 0, 0)); // 下午 2 點
var orderService = new OrderService(fakeTimeProvider);
// Act
var result = orderService.CanPlaceOrder();
// Assert
result.Should().BeTrue();
}
有些測試場景需要讓時間「凍結」在特定時點:
[Fact]
public void ProcessBatch_在固定時間點_應正確處理()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
var fixedTime = new DateTime(2024, 12, 25, 10, 30, 0); // 聖誕節上午10:30
fakeTimeProvider.SetLocalNow(fixedTime);
var processor = new BatchProcessor(fakeTimeProvider);
// Act & Assert
var result1 = processor.ProcessItem("Item1");
var result2 = processor.ProcessItem("Item2");
// 兩次處理的時間戳應該完全相同
result1.Timestamp.Should().Be(result2.Timestamp);
}
很多業務邏輯需要等待一段時間才能看到結果,比如快取過期、Token 失效等。在測試中不可能真的等幾個小時,這時就需要用時間快轉來模擬時間流逝:
[Fact]
public void CacheExpiry_經過過期時間_應清除快取()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
fakeTimeProvider.SetLocalNow(new DateTime(2024, 3, 15, 10, 0, 0));
var cache = new TimedCache(fakeTimeProvider, TimeSpan.FromMinutes(30));
cache.Set("key", "value");
// Act - 快轉時間到31分鐘後
fakeTimeProvider.Advance(TimeSpan.FromMinutes(31));
// Assert
var result = cache.Get("key");
result.Should().BeNull();
}
在金融系統或資料分析領域,經常需要重播歷史資料來驗證演算法的正確性。這時需要將時間倒轉到過去的某個時點:
[Fact]
public void HistoricalDataProcessor_回到過去時間_應正確處理歷史資料()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
// 回到2020年的某一天
var historicalTime = new DateTime(2020, 1, 15, 9, 0, 0);
fakeTimeProvider.SetLocalNow(historicalTime);
var processor = new HistoricalDataProcessor(fakeTimeProvider);
// Act
var result = processor.ProcessDataForDate(historicalTime.Date);
// Assert
result.Should().NotBeNull();
result.ProcessedAt.Should().Be(historicalTime);
}
接下來看看幾個真實世界的應用場景,了解 TimeProvider 在不同業務邏輯中的實際用法。
排程系統是時間相依性最明顯的場景之一。需要根據當前時間判斷工作是否應該執行,並計算下次執行時間:
public class JobSchedule
{
public DateTime NextExecutionTime { get; set; }
public string CronExpression { get; set; } = string.Empty;
}
public class ScheduleService
{
private readonly TimeProvider _timeProvider;
public ScheduleService(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public bool ShouldExecuteJob(JobSchedule schedule)
{
var now = _timeProvider.GetLocalNow();
return schedule.NextExecutionTime <= now;
}
public DateTime CalculateNextExecution(JobSchedule schedule)
{
var now = _timeProvider.GetLocalNow();
return schedule.CronExpression switch
{
"0 0 * * *" => now.Date.AddDays(1), // 每日午夜
"0 0 * * 1" => GetNextMonday(now), // 每週一午夜
_ => now.DateTime.AddHours(1) // 預設每小時
};
}
private DateTime GetNextMonday(DateTimeOffset now)
{
var daysUntilMonday = ((int)DayOfWeek.Monday - (int)now.DayOfWeek + 7) % 7;
return now.Date.AddDays(daysUntilMonday == 0 ? 7 : daysUntilMonday);
}
}
測試排程系統:
public class ScheduleServiceTests
{
[Theory]
[InlineData("2024-03-15 14:30:00", "2024-03-15 14:00:00", true)] // 已到執行時間
[InlineData("2024-03-15 13:30:00", "2024-03-15 14:00:00", false)] // 尚未到執行時間
public void ShouldExecuteJob_根據時間判斷_應回傳正確結果(
string currentTimeStr,
string scheduledTimeStr,
bool expected)
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
var currentTime = DateTime.Parse(currentTimeStr);
var scheduledTime = DateTime.Parse(scheduledTimeStr);
fakeTimeProvider.SetLocalNow(currentTime);
var schedule = new JobSchedule { NextExecutionTime = scheduledTime };
var service = new ScheduleService(fakeTimeProvider);
// Act
var result = service.ShouldExecuteJob(schedule);
// Assert
result.Should().Be(expected);
}
}
快取系統通常會設定過期時間來確保資料的新鮮度。在測試這類功能時,不可能真的等幾分鐘來驗證快取是否過期,這時 TimeProvider 的時間控制能力就很有用:
public record CacheItem<T>(T Value, DateTimeOffset ExpiryTime);
public class TimedCache<T>
{
private readonly TimeProvider _timeProvider;
private readonly Dictionary<string, CacheItem<T>> _cache = new();
public TimedCache(TimeProvider timeProvider, TimeSpan defaultExpiry)
{
_timeProvider = timeProvider;
DefaultExpiry = defaultExpiry;
}
public TimeSpan DefaultExpiry { get; }
public void Set(string key, T value, TimeSpan? expiry = null)
{
var expiryTime = _timeProvider.GetUtcNow().Add(expiry ?? DefaultExpiry);
_cache[key] = new CacheItem<T>(value, expiryTime);
}
public T? Get(string key)
{
if (!_cache.TryGetValue(key, out var item))
{
return default;
}
if (item.ExpiryTime <= _timeProvider.GetUtcNow())
{
_cache.Remove(key);
return default;
}
return item.Value;
}
}
測試快取過期:
fakeTimeProvider.Advance()
這個方法的用途是將 FakeTimeProvider 內部所維護的「現在時間」往前推進指定的時間間隔(例如 3 分鐘)。這通常用於單元測試中,模擬時間的流逝,讓你可以測試與時間相關的邏輯(如快取過期、營業時間判斷等),而不需要真的等待時間過去。
基本概念:
Advance(TimeSpan.FromMinutes(3))
會讓 FakeTimeProvider 的「現在時間」增加 3 分鐘方法特性:
常見使用場景:
與真實等待的比較:
// X 真實測試 - 需要等待 5 分鐘
Thread.Sleep(TimeSpan.FromMinutes(5)); // 測試執行緩慢
// O 模擬測試 - 瞬間完成
fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); // 瞬間時間跳躍
[Fact]
public void Cache_設定項目後快轉時間_應正確處理過期()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
var startTime = new DateTime(2024, 3, 15, 10, 0, 0);
fakeTimeProvider.SetLocalNow(startTime);
var cache = new TimedCache<string>(fakeTimeProvider, TimeSpan.FromMinutes(5));
// Act & Assert - 設定快取項目(時間點:10:00)
cache.Set("key1", "value1");
cache.Get("key1").Should().Be("value1");
// 模擬時間前進 3 分鐘(時間點:10:03),快取尚未過期(5分鐘期限)
fakeTimeProvider.Advance(TimeSpan.FromMinutes(3));
cache.Get("key1").Should().Be("value1"); // 3 < 5,仍在有效期內
// 再次模擬時間前進 3 分鐘(時間點:10:06),快取已過期
fakeTimeProvider.Advance(TimeSpan.FromMinutes(3)); // 總計 6 分鐘 > 5 分鐘期限
cache.Get("key1").Should().BeNull(); // 已過期,返回 null
}
使用 Advance()
方法的最大價值在於邊界測試和可重複性:
金融交易系統有嚴格的交易時間限制,只有在特定時間窗口內才能進行交易。這類業務規則通常涉及複雜的時間判斷邏輯:
public class TradingService
{
private readonly TimeProvider _timeProvider;
public TradingService(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public bool IsInTradingHours()
{
var now = _timeProvider.GetLocalNow();
var currentTime = now.TimeOfDay;
// 交易時間:9:00-11:30, 13:00-15:00
return (currentTime >= TimeSpan.FromHours(9) && currentTime <= TimeSpan.FromHours(11.5)) ||
(currentTime >= TimeSpan.FromHours(13) && currentTime <= TimeSpan.FromHours(15));
}
public decimal GetMarketMultiplier()
{
var now = _timeProvider.GetLocalNow();
return now.DayOfWeek switch
{
DayOfWeek.Saturday or DayOfWeek.Sunday => 0m, // 週末不交易
DayOfWeek.Friday when now.Hour >= 14 => 1.1m, // 週五下午波動較大
_ => 1.0m
};
}
}
測試交易時間窗口:
[Theory]
[InlineData("09:30:00", true)] // 上午交易時間
[InlineData("11:15:00", true)] // 上午交易時間結束前
[InlineData("12:00:00", false)] // 中午休息時間
[InlineData("14:30:00", true)] // 下午交易時間
[InlineData("15:30:00", false)] // 下午交易結束後
public void IsInTradingHours_不同時間點_應回傳正確結果(string timeStr, bool expected)
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
var testTime = DateTime.Today.Add(TimeSpan.Parse(timeStr));
fakeTimeProvider.SetLocalNow(testTime);
var service = new TradingService(fakeTimeProvider);
// Act
var result = service.IsInTradingHours();
// Assert
result.Should().Be(expected);
}
在學習了各種時間控制技術和實戰應用場景後,讓我們來看看如何結合 AutoFixture 與 TimeProvider,讓測試更有效率和簡潔。
在開始深入 AutoFixture 整合之前,建議讀者先比較一下範例專案中的兩個測試類別:
OrderServiceTests.cs
:傳統測試寫法,手動建立所有測試物件OrderServiceAutoFixtureTests.cs
:AutoFixture 測試寫法,自動化物件建立透過對比這兩個類別的相同測試案例,可以清楚看到兩種寫法的差異和各自的優勢。特別注意觀察:
new FakeTimeProvider()
和 new OrderService()
[Frozen]
和依賴注入自動處理透過前面的範例,我們已經看到許多測試都需要重複的準備工作:
AutoFixture 可以自動化這些重複性工作,讓我們專注於測試邏輯本身。
首先建立一個自訂的 AutoFixture Customization:
public class FakeTimeProviderCustomization : ICustomization
{
public void Customize(IFixture fixture)
{
fixture.Register(() => new FakeTimeProvider());
}
}
建立一個整合了 NSubstitute 和 FakeTimeProvider 的自訂屬性:
public class AutoDataWithCustomizationAttribute : AutoDataAttribute
{
public AutoDataWithCustomizationAttribute() : base(CreateFixture)
{
}
private static IFixture CreateFixture()
{
var fixture = new Fixture()
.Customize(new AutoNSubstituteCustomization())
.Customize(new FakeTimeProviderCustomization());
return fixture;
}
}
為了清楚展示 AutoFixture 的優勢,讓我們比較兩種寫法:
[Fact]
public void CanPlaceOrder_在營業時間內_傳統寫法()
{
// Arrange - 需要手動建立所有物件
var fakeTimeProvider = new FakeTimeProvider();
fakeTimeProvider.SetLocalNow(new DateTime(2024, 3, 15, 14, 0, 0));
var orderService = new OrderService(fakeTimeProvider);
// Act
var result = orderService.CanPlaceOrder();
// Assert
result.Should().BeTrue();
}
[Theory]
[AutoDataWithCustomization]
public void GetTimeBasedDiscount_週一_應回傳無優惠(
[Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
OrderService sut) // sut = System Under Test,由 AutoFixture 自動建立
{
// Arrange - 只需要設定測試相關的時間
var mondayTime = new DateTime(2024, 3, 11, 14, 0, 0); // 2024/3/11 是週一
fakeTimeProvider.SetLocalNow(mondayTime);
// Act
var discount = sut.GetTimeBasedDiscount();
// Assert
discount.Should().Be("無優惠");
}
如果直接使用 [Frozen] FakeTimeProvider
,會遇到類型不匹配問題:
// X 這樣寫會失敗
[Theory]
[AutoDataWithCustomization]
public void Test([Frozen] FakeTimeProvider provider, OrderService sut)
{
// OrderService 建構式需要 TimeProvider,但 AutoFixture 只知道 FakeTimeProvider
// 這會導致類型不匹配錯誤
}
正確的解決方案是使用 Matching.DirectBaseType
:
// O 正確的寫法
[Theory]
[AutoDataWithCustomization]
public void Test([Frozen(Matching.DirectBaseType)] FakeTimeProvider provider, OrderService sut)
{
// AutoFixture 會將 FakeTimeProvider 也註冊為 TimeProvider 使用
}
核心概念:
Matching.DirectBaseType
告訴 AutoFixture:「當需要基底類型(TimeProvider)時,使用這個衍生類型的實例(FakeTimeProvider)」實際運作流程:
[Frozen]
標記的實例可以滿足這個需求[Frozen(Matching.DirectBaseType)] FakeTimeProvider
為了更清楚展示 AutoFixture 的優勢,我們建立了專門的對比測試類別:
傳統測試:OrderServiceTests.cs
- 展示手動建立物件的傳統寫法
AutoFixture 測試:OrderServiceAutoFixtureTests.cs
- 展示自動化物件建立的現代寫法
讓我們看看幾個實際應用的測試範例:
[Theory]
[AutoDataWithCustomization]
public void GetTimeBasedDiscount_週五測試_應回傳九折優惠(
[Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
OrderService sut)
{
// Arrange - 設定為週五
var fridayTime = new DateTime(2024, 3, 15, 14, 0, 0); // 2024/3/15 是週五
fakeTimeProvider.SetLocalNow(fridayTime);
// Act
var discount = sut.GetTimeBasedDiscount();
// Assert
discount.Should().Be("週五快樂:九折優惠");
}
[Theory]
[AutoDataWithCustomization]
public void CanPlaceOrder_營業時間邊界測試_使用不同實例(
[Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
OrderService sut)
{
// Arrange & Act & Assert - 上午9點整(營業時間開始)
fakeTimeProvider.SetLocalNow(new DateTime(2024, 3, 15, 9, 0, 0));
sut.CanPlaceOrder().Should().BeTrue();
}
[Theory]
[AutoDataWithCustomization]
public void TimedCache_使用AutoFixture測試過期機制_應正確處理(
[Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
string key, // AutoFixture 自動產生
string value) // AutoFixture 自動產生
{
// Arrange
var startTime = new DateTime(2024, 3, 15, 10, 0, 0);
fakeTimeProvider.SetLocalNow(startTime);
var cache = new TimedCache<string>(fakeTimeProvider, TimeSpan.FromMinutes(30));
// Act & Assert - 設定和立即取得
cache.Set(key, value);
cache.Get(key).Should().Be(value);
// Act & Assert - 快轉時間後應過期
fakeTimeProvider.Advance(TimeSpan.FromMinutes(31));
cache.Get(key).Should().BeNull();
}
結合 AutoFixture 與 TimeProvider 的主要優勢:
建議使用的情況:
可以考慮傳統寫法的情況:
仔細觀察 OrderServiceTests.cs
中的 CanPlaceOrder_不同時間點_應回傳正確結果
測試方法,它使用了 [Theory]
和 [InlineData]
來測試多個時間點的情況:
[Theory]
[InlineData(8, false)] // 上午8點 - 營業時間前
[InlineData(9, true)] // 上午9點 - 剛開始營業
[InlineData(12, true)] // 中午12點 - 營業時間內
[InlineData(16, true)] // 下午4點 - 營業時間內
[InlineData(17, false)] // 下午5點 - 剛結束營業
[InlineData(18, false)] // 下午6點 - 營業時間後
public void CanPlaceOrder_不同時間點_應回傳正確結果(int hour, bool expected)
這個測試案例結合了參數化測試的優勢,但仍需要手動建立 FakeTimeProvider
和 OrderService
。
思考題:還記得 Day 12 – 結合 AutoData:xUnit 與 AutoFixture 的整合應用
學過的 InlineAutoDataAttribute
嗎?我們是否可以將這個測試改寫成:
[Theory]
[InlineAutoDataWithCustomization(8, false)]
[InlineAutoDataWithCustomization(9, true)]
// ... 其他測試案例
public void CanPlaceOrder_不同時間點_AutoFixture版本(
int hour,
bool expected,
[Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
OrderService sut)
要實現這個功能,你需要:
InlineAutoDataAttribute
這樣的整合可以結合參數化測試的靈活性和 AutoFixture 的自動化優勢,進一步減少測試程式碼的重複性。
有興趣的朋友可以嘗試實作看看,這是一個很好的練習,可以加深對 AutoFixture 客製化的理解。
在實際專案中設定 TimeProvider 的依賴注入時,需要區分不同環境的需求:
// 生產環境
services.AddSingleton(TimeProvider.System);
// 開發環境(如果需要特定時間測試)
if (isDevelopment)
{
var fakeTimeProvider = new FakeTimeProvider();
fakeTimeProvider.SetLocalNow(new DateTime(2024, 12, 25, 10, 0, 0)); // 測試用時間
services.AddSingleton<TimeProvider>(fakeTimeProvider);
}
FakeTimeProvider
本身是執行緒安全的,這表示多個執行緒可以同時安全地呼叫其方法:
GetUtcNow()
、GetLocalNow()
等方法可以被多個執行緒同時呼叫SetUtcNow()
、Advance()
等方法內部有適當的同步機制在測試多執行緒程式碼時,需要注意以下幾點:
[Fact]
public async Task ConcurrentOperations_使用相同TimeProvider_應保持一致性()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
fakeTimeProvider.SetLocalNow(new DateTime(2024, 3, 15, 10, 0, 0));
var service = new TimeService(fakeTimeProvider);
// Act - 並行執行多個操作
var tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() => service.GetCurrentTimeString()))
.ToArray();
var results = await Task.WhenAll(tasks);
// Assert - 所有結果應該相同(因為時間被凍結)
results.Should().AllBe(results[0]);
}
[Fact]
public async Task TimeAdvancement_在並行讀取期間_應保持原子性()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
var startTime = new DateTime(2024, 3, 15, 10, 0, 0);
fakeTimeProvider.SetLocalNow(startTime);
var service = new TimeService(fakeTimeProvider);
var readTasks = new List<Task<DateTime>>();
// Act - 同時進行讀取和時間推進
for (int i = 0; i < 50; i++)
{
readTasks.Add(Task.Run(() => service.GetCurrentTime()));
// 每隔幾次讀取就推進時間
if (i % 10 == 0)
{
Task.Run(() => fakeTimeProvider.Advance(TimeSpan.FromMinutes(1)));
}
}
var results = await Task.WhenAll(readTasks);
// Assert - 所有結果都應該是有效的時間值
results.Should().AllSatisfy(time => time.Should().BeAfter(startTime.AddMinutes(-1)));
}
// X 錯誤:假設時間在整個測試過程中固定
[Fact]
public async Task BadExample_假設時間不變()
{
var fakeTimeProvider = new FakeTimeProvider();
var service = new TimeService(fakeTimeProvider);
var time1 = service.GetCurrentTime();
// 如果有其他執行緒呼叫 Advance(),這個假設就會失敗
await Task.Delay(100);
var time2 = service.GetCurrentTime();
time1.Should().Be(time2); // 可能失敗!
}
// O 正確:明確控制時間
[Fact]
public async Task GoodExample_明確控制時間()
{
var fakeTimeProvider = new FakeTimeProvider();
var fixedTime = new DateTime(2024, 3, 15, 10, 0, 0);
fakeTimeProvider.SetLocalNow(fixedTime);
var service = new TimeService(fakeTimeProvider);
var time1 = service.GetCurrentTime();
// 明確說明:在沒有 Advance() 的情況下,時間應該相同
var time2 = service.GetCurrentTime();
time1.Should().Be(time2);
}
// X 錯誤:多個測試共用同一個實例
public class BadTestClass
{
private static readonly FakeTimeProvider SharedProvider = new();
[Fact] public void Test1() { /* 可能互相干擾 */ }
[Fact] public void Test2() { /* 可能互相干擾 */ }
}
// O 正確:每個測試使用獨立實例
public class GoodTestClass
{
[Fact]
public void Test1()
{
var fakeTimeProvider = new FakeTimeProvider(); // 獨立實例
// 測試邏輯
}
[Fact]
public void Test2()
{
var fakeTimeProvider = new FakeTimeProvider(); // 獨立實例
// 測試邏輯
}
}
在測試類別中使用 FakeTimeProvider 時,要確保每個測試都有獨立的時間環境,避免測試之間互相影響:
public class TimeServiceTests : IDisposable
{
private readonly FakeTimeProvider _fakeTimeProvider;
private readonly TimeService _sut;
public TimeServiceTests()
{
_fakeTimeProvider = new FakeTimeProvider();
_sut = new TimeService(_fakeTimeProvider);
}
public void Dispose()
{
// FakeTimeProvider 實作了 IDisposable
_fakeTimeProvider?.Dispose();
}
[Fact]
public void Test1()
{
_fakeTimeProvider.SetLocalNow(new DateTime(2024, 1, 1));
// 測試邏輯...
}
[Fact]
public void Test2()
{
_fakeTimeProvider.SetLocalNow(new DateTime(2024, 12, 31));
// 測試邏輯...
}
}
開發全球化應用時,時區處理是個複雜的議題。TimeProvider 可以方便地測試不同時區的邏輯:
public class GlobalTimeService
{
private readonly TimeProvider _timeProvider;
public GlobalTimeService(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public DateTimeOffset GetTimeInTimeZone(string timeZoneId)
{
var utcNow = _timeProvider.GetUtcNow();
var targetTimeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
return TimeZoneInfo.ConvertTime(utcNow, targetTimeZone);
}
}
測試不同時區:
[Theory]
[InlineData("UTC", "2024-03-15 10:00:00")]
[InlineData("Tokyo Standard Time", "2024-03-15 19:00:00")]
[InlineData("Eastern Standard Time", "2024-03-15 06:00:00")]
public void GetTimeInTimeZone_不同時區_應回傳正確時間(string timeZoneId, string expectedTimeStr)
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
var baseUtcTime = new DateTime(2024, 3, 15, 10, 0, 0, DateTimeKind.Utc);
fakeTimeProvider.SetUtcNow(baseUtcTime);
var service = new GlobalTimeService(fakeTimeProvider);
var expectedTime = DateTime.Parse(expectedTimeStr);
// Act
var result = service.GetTimeInTimeZone(timeZoneId);
// Assert
result.DateTime.Should().BeCloseTo(expectedTime, TimeSpan.FromSeconds(1));
}
在高頻率的時間查詢場景下,需要確認 FakeTimeProvider 的效能是否符合需求:
[Fact]
public void FakeTimeProvider_大量時間查詢_效能測試()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
fakeTimeProvider.SetLocalNow(DateTime.Now);
var stopwatch = Stopwatch.StartNew();
// Act - 執行大量時間查詢
for (int i = 0; i < 1_000_000; i++)
{
var _ = fakeTimeProvider.GetUtcNow();
}
stopwatch.Stop();
// Assert - 效能應該在可接受範圍內
stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000); // 1秒內完成百萬次查詢
}
雖然 FakeTimeProvider 的記憶體使用量不大,但在建立大量測試實例時還是要注意資源管理:
[Fact]
public void FakeTimeProvider_生命週期管理_應正確釋放資源()
{
// Arrange & Act
var providers = new List<FakeTimeProvider>();
for (int i = 0; i < 1000; i++)
{
var provider = new FakeTimeProvider();
provider.SetLocalNow(DateTime.Now.AddDays(i));
providers.Add(provider);
}
// Assert - 記憶體使用應該穩定
GC.Collect();
GC.WaitForPendingFinalizers();
var memoryBefore = GC.GetTotalMemory(false);
// 釋放資源
foreach (var provider in providers)
{
provider.Dispose();
}
providers.Clear();
GC.Collect();
GC.WaitForPendingFinalizers();
var memoryAfter = GC.GetTotalMemory(false);
// 記憶體應該有顯著減少
(memoryBefore - memoryAfter).Should().BeGreaterThan(0);
}
透過 Microsoft.Bcl.TimeProvider,徹底解決了時間測試的根本問題。
透過 Microsoft.Bcl.TimeProvider,真正解決了時間測試的根本問題。不再需要擔心測試會因為執行時間而失敗,也不用為了測試特定時間點而等到半夜執行程式。
明天會處理檔案與 IO 測試的問題,看看如何用 System.IO.Abstractions 讓檔案系統操作變得可測試。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第十六天。明天會介紹 Day 17 – 檔案與 IO 測試:使用 System.IO.Abstractions 模擬檔案系統 - 實現可測試的檔案操作。